Skip to content

Kernel perf: fix opt-level, stop allocating — and draw a fractal - #91

Merged
ecto merged 2 commits into
mainfrom
claude/kernel-perf-slab
Aug 18, 2026
Merged

Kernel perf: fix opt-level, stop allocating — and draw a fractal#91
ecto merged 2 commits into
mainfrom
claude/kernel-perf-slab

Conversation

@ecto

@ecto ecto commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Follow-up to #90. Two changes, one negative result, and one gratuitous thing.

opt-level dominates everything

The crate was scaffolded with opt-level = "z", which suppresses the inlining
a dispatch loop lives on. Moving to 3 (with codegen-units = 1) is worth
3.3× on the call-heavy benchmark and 1.5× on the loop benchmark, measured
back to back on the same machine. Nothing else here came close.

Allocation was never the bottleneck

The interpreter allocated four times per call — two Rc::new(Vec::new()) for
empty capture lists, the operand vector, and the register file — plus one per
jump for the block params. Now: one shared empty-captures Rc, a pool of
retired register files, operands read into an inline buffer, and params
borrowed out of the module instead of cloned. Tail calls recycle their own
outgoing register file, which a tail-recursive loop needs since it never
returns through a frame.

That took the benchmark from 89,858 allocations to 47 and bought no
measurable time.
Interleaved A/B runs put the two builds inside each other's
noise. #90's description claimed allocation was the bottleneck; that was wrong,
and the README and design doc now say so.

The change is kept on different grounds: interpreting in near-constant memory
is worth having in a kernel with no OOM killer behind the heap. It is not a
speedup.

A slab allocator is therefore not built. There is nothing left for it to
allocate, and even at 90k allocations the first-fit list was not costing
measurable time. Building it would add complexity to a path the measurements
say does not matter.

The kernel draws a fractal

boot/mandel.oo renders the Mandelbrot set in ASCII and the unikernel runs it
after init. Every float op, closure application and string concat goes through
the bare-metal interpreter — no libm, no libc, no OS — and the output is
byte-identical to the host. make check now enforces that alongside init, so
the fractal doubles as the float path's parity test.

Adds Float/Int intrinsics to the kernel builtins on the way (string parse
via core's parser, deliberately, to match the host exactly).

Reviewer notes

  • Benchmark harness. boot/bench.oo (calls, sequences), boot/loop.oo
    (tail recursion → Recur, so no frame pushes), exact op counts from the
    VM's dispatch counter, exact allocation counts from the global allocator,
    and bench.sh taking a minimum over N boots. Op and alloc counts are
    deterministic; wall-clock inside an emulator on a shared machine is only
    ever biased upward, so treat any timing delta under ~30% as noise unless it
    reproduces under interleaving. (Another project was pegging the machine at
    1200% CPU during this work; identical code ranged 510–1825 ns/op.)
  • Remaining gap: ~500 ns/op against ~7 ns/op for a minimal native dispatch
    loop on the same emulator. loop.oo and bench.oo cost about the same per
    op, so it is per-op dispatch, not calling. Chasing it needs an idle machine
    and a profiler.
  • Small language gap surfaced: the checker types float as Str → Float
    even though the runtime accepts Int, so int→float needs the
    [float [str x]] detour. Worth a checker fix; noted, not done here.
  • The register pool touches frame lifetime, which is where continuation
    bugs hide. make check still passes (forwarding, abort, non-tail resume all
    identical to host), and recycle refuses zero-capacity vectors — parking
    one would evict a real buffer and hand the next call something it has to
    grow from nothing (which briefly regressed bench to 11k allocs before I
    caught it).
  • Kernel builds with zero warnings and is clippy-clean; cargo test --workspace green; unikernel_boot test passes locally and skips on CI as
    designed.

🤖 Generated with Claude Code

ecto and others added 2 commits August 18, 2026 10:48
Two changes and one negative result.

**opt-level.** The crate was scaffolded `opt-level = "z"`, which suppresses
the inlining a dispatch loop lives on. Moving to 3 (with codegen-units = 1)
is worth 3.3x on the call-heavy benchmark and 1.5x on the loop benchmark,
measured back to back on the same machine. This dominates everything else
here; the image is small either way.

**Allocation.** The interpreter allocated four times per call — two
`Rc::new(Vec::new())` for empty capture lists, the operand vector, and the
register file — plus one per jump for the block params. Now: one shared
empty-captures Rc, a pool of retired register files, operands read into an
inline buffer, and params borrowed out of the module instead of cloned.
Tail calls recycle their own outgoing register file, which is what a
tail-recursive loop needs since it never returns through a frame.

That took the benchmark from 89,858 allocations to 47, and **bought no
measurable time.** Interleaved A/B runs put the two builds inside each
other's noise. The PR that landed the kernel claimed allocation was the
bottleneck; that was wrong. The change is kept because interpreting in
near-constant memory is worth having in a kernel with no OOM killer behind
the heap — not as a speedup. A slab allocator, which is where this was
heading, is not worth building: there is nothing left for it to allocate,
and even at 90k allocations the first-fit list was not costing measurable
time.

Adds the harness this was measured with: boot/bench.oo (calls, sequences),
boot/loop.oo (tail recursion, so no frame pushes), exact op counts from the
VM's dispatch counter, exact allocation counts from the global allocator,
and bench.sh to take a minimum over several boots because wall-clock inside
an emulator on a shared machine is only ever biased upward.

Remaining: ~500 ns/op against ~7 ns/op for a minimal native dispatch loop on
the same emulator. loop.oo and bench.oo cost about the same per op, so the
gap is per-op dispatch, not calling. Chasing it needs an idle machine and a
profiler rather than more guessing.

`make check` still passes: same output on the host and on bare metal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
boot/mandel.oo renders the Mandelbrot set in ASCII, and the unikernel runs it
after init. Every float op, closure application and string concat goes
through the bare-metal interpreter — no libm, no libc, no OS — and the
output is byte-identical to the host, which `make check` now enforces
alongside init. So it is gratuitous, but it is also the float path's
parity check in disguise.

Adds Float/Int intrinsics to the kernel builtins on the way (int<->float
conversion, string parse via core's parser to match the host exactly).

Also surfaced a small language gap: the checker types `float` as
Str -> Float even though the runtime accepts Int, so int->float needs the
`[float [str x]]` detour. Worth fixing in the checker; noted, not done here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 18, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
loon Ready Ready Preview Aug 18, 2026 3:18pm

Request Review

@ecto
ecto merged commit d0e24e7 into main Aug 18, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant